【React】useOptimistic で即時レスポンスを実現する楽観的 UI 更新を実装する

【React】useOptimistic で即時レスポンスを実現する楽観的 UI 更新を実装する

「ボタンを押したのに反応が遅い...」そんな経験ありませんか? React の新しい魔法のような機能 useOptimistic を使えば、ユーザーの操作にすぐに反応する爽快な UI が作れちゃいます! ユーザー体験を劇的に向上させる秘訣、ぜひ一緒に学んでいきましょう!
Clock Icon2024.10.31

環境

  • 今回使用するuseOptimisticは、2024 年 10 月時点では、React の Canary リリースおよび experimental チャンネルでのみ利用可能です。
  • そのため、本記事では Canary ビルドを使用してサンプルコードを記載しています。
"react": "19.0.0-rc-f2df5694-20240916",
"react-dom": "19.0.0-rc-f2df5694-20240916",
"@types/react": "npm:types-react@19.0.0-rc.0",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.0",
"msw": "^2.4.4",

完成イメージ

  • useOptimistic を使用したお気に入りボタンの動作を示しています。ボタンをクリックすると即座に UI が更新され、その後バックグラウンドでサーバー通信が行われています。

完成イメージ.gif

useOptimistic とは

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
  • useOptimistic は React の新しい実験的なフックで、ユーザーインターフェースの即時的な反応性を向上させるために設計されています。
    • state: 現在の状態
    • updateFn: 楽観的な更新を行う関数
    • optimisticState: 楽観的に更新された状態
    • addOptimistic: 楽観的な更新をトリガーする関数
  • このフックを使用することで、開発者はユーザーアクションに対して楽観的 UI 更新を簡単に実装できます。

https://ja.react.dev/reference/react/useOptimistic

実装

まずは、useOptimisticを使用しない、通常の UI 更新を行うコンポーネントを作成します。

src/components/FavoriteButton.tsx
import axios from "axios";
import clsx from "clsx";
import { useState, useCallback } from "react";
import { MdFavorite, MdFavoriteBorder } from "react-icons/md";

type FavoriteResponse = {
  isFavorite: boolean;
  favoriteCount: number;
};

export const FavoriteButton = () => {
  const [response, setResponse] = useState<FavoriteResponse>({
    isFavorite: false,
    favoriteCount: 0,
  });
  const [isPending, setIsLoading] = useState(false);

  const postLike = useCallback(async () => {
    if (isPending) {
      return;
    } else {
      setIsLoading(true);
    }

    try {
      const res = await axios.post<FavoriteResponse>(
        "https://example.com/favorite",
      );
      setResponse(res.data);
    } catch (error) {
      console.error("Failed to update favorite:", error);
    } finally {
      setIsLoading(false);
    }
  }, [isPending]);

  return (
    <div
      className={clsx(
        "flex min-w-16 cursor-pointer flex-row items-center justify-center gap-2",
        isPending && "cursor-default opacity-50"
      )}
      onClick={postLike}
    >
      {response.isFavorite ? <MdFavorite /> : <MdFavoriteBorder />}
      <div className="text-2xl">{response.favoriteCount}</div>
    </div>
  );
};
  • useState を使用して状態を管理し、isPending フラグでローディング状態を制御しています。
  • postLike 関数は非同期で実行され、サーバーからのレスポンスを待ってから状態を更新します。
msw の実装

この部分は Mock Service Worker (MSW) を使用した API モックの実装です。

src/mocks/handlers.ts
import { http, HttpResponse } from "msw";

let favoriteCount = 0;
let isFavorite = false;

export const handlers = [
  http.post("https://example.com/favorite", async () => {
    // APIでの遅延を模擬
    await new Promise((resolve) => setTimeout(resolve, 2000));

    isFavorite = !isFavorite;
    if (isFavorite) {
      favoriteCount++;
    } else {
      favoriteCount--;
    }

    return HttpResponse.json(
      {
        isFavorite: isFavorite,
        favoriteCount: favoriteCount,
      },
      { status: 200 }
    );
  }),
];
  • 実際のサーバー通信を模倣し、2 秒の遅延を設けています。

通常イメージ.gif

  • お気に入りボタンを作成しました!
  • ユーザーがボタンをクリックすると、postLike関数が呼び出され、サーバーにお気に入り登録のリクエストが送信されます。
  • そのリクエストが完了するまで、ボタンはローディング状態になり、お気に入り状態も更新されません。
  • 私個人的には、もっさり感があってお気に入り押すのやめようかなと思ってしまいます。

useOptimistic を使用した実装

  • ここからは、先ほどのコードにuseOptimisticを追加して、楽観的 UI 更新を実装していこうと思います!
  • useTransitionも使用するので、useTransitionの知見がない場合は、以下も参考にしてください!

https://dev.classmethod.jp/articles/usetransition/

  • まずはisPendinguseStateからuseTransitionに変更します!
  • useTransition を使用することで、状態更新の優先度を下げ、UI のレスポンシブ性を向上させることができます。ただし、この段階ではまだ楽観的UI 更新は実現できていません。
src/components/FavoriteButton.tsx
import axios from "axios";
import clsx from "clsx";
+ import { useState, useCallback, useTransition } from "react";
import { MdFavorite, MdFavoriteBorder } from "react-icons/md";

type FavoriteResponse = {
  isFavorite: boolean;
  favoriteCount: number;
};

export const FavoriteButton = () => {
  const [response, setResponse] = useState<FavoriteResponse>({
    isFavorite: false,
    favoriteCount: 0,
  });
+  const [isPending, startTransition] = useTransition();

+  const postLike = useCallback(() => {
    if (isPending) {
      return;
-    } else {
-      setIsLoading(true);
-    }

+    startTransition(async () => {
      try {
        const res = await axios.post<FavoriteResponse>(
          "https://example.com/favorite",
        );
        setResponse(res.data);
      } catch (error) {
        console.error("Failed to update favorite:", error);
-      } finally {
-        setIsLoading(false);
-      }
+    });
  }, [isPending]);

  return (
    <div
      className={clsx(
        "flex min-w-16 cursor-pointer flex-row items-center justify-center gap-2",
        isPending && "cursor-default opacity-50"
      )}
      onClick={postLike}
    >
      {response.isFavorite ? <MdFavorite /> : <MdFavoriteBorder />}
      <div className="text-2xl">{response.favoriteCount}</div>
    </div>
  );
};

  • 次に、useOptimisticを使用して楽観的 UI 更新を実装していきます!
src/components/FavoriteButton.tsx
import axios from "axios";
import clsx from "clsx";
+ import { useState, useCallback, useTransition, useOptimistic } from "react";
import { MdFavorite, MdFavoriteBorder } from "react-icons/md";

type FavoriteResponse = {
  isFavorite: boolean;
  favoriteCount: number;
};

export const FavoriteButton = () => {
  const [response, setResponse] = useState<FavoriteResponse>({
    isFavorite: false,
    favoriteCount: 0,
  });
  const [isPending, startTransition] = useTransition();
+  const [optimisticResponse, addOptimisticResponse] = useOptimistic(
+    response,
+    (currentState: FavoriteResponse, optimisticUpdate: FavoriteResponse) => ({
+      ...currentState, // 今回は不要だが、他のプロパティがある場合には必要
+      ...optimisticUpdate,
+    })
+  );

  const postLike = useCallback(() => {
    if (isPending) {
      return;
    }

    startTransition(async () => {
+      addOptimisticResponse({
+        isFavorite: !optimisticResponse.isFavorite,
+        favoriteCount: optimisticResponse.favoriteCount + (optimisticResponse.isFavorite ? -1 : 1),
+      });

      try {
        const res = await axios.post<FavoriteResponse>(
          "https://example.com/favorite",
        );
        setResponse(res.data);
      } catch (error) {
+       setResponse(response);
        console.error("Failed to update favorite:", error);
      }
    });
+ }, [isPending, optimisticResponse, response, addOptimisticResponse]);

  return (
    <div
      className={clsx(
        "flex min-w-16 cursor-pointer flex-row items-center justify-center gap-2",
        isPending && "cursor-default opacity-50"
      )}
      onClick={postLike}
    >
+      {optimisticResponse.isFavorite ? <MdFavorite /> : <MdFavoriteBorder />}
+      <div className="text-2xl">{optimisticResponse.favoriteCount}</div>
    </div>
  );
};
  • ユーザーがボタンをクリックすると、即座に楽観的な状態更新が行われ、UI が更新されます。
  • 同時に、バックグラウンドでサーバー通信が行われ、実際の状態が更新されます。
  • エラーが発生した場合は、元の状態に戻すことで整合性を保っています。
  • この実装により、楽観的UI更新 を実現しています。

途中イメージV2.gif

  • mswのレスポンスのステータスを500にした場合

エラーイメージV2.gif


  • 最後に楽観的 UI 更新を実装したので、isPendingで動作を切り分けている箇所が不要になったので、削除します!
src/components/FavoriteButton.tsx
import axios from "axios";
- import clsx from "clsx";
import { useState, useCallback, useTransition, useOptimistic } from "react";
import { MdFavorite, MdFavoriteBorder } from "react-icons/md";

type FavoriteResponse = {
  isFavorite: boolean;
  favoriteCount: number;
};

export const FavoriteButton = () => {
  const [response, setResponse] = useState<FavoriteResponse>({
    isFavorite: false,
    favoriteCount: 0,
  });
+  const [, startTransition] = useTransition();
  const [optimisticResponse, addOptimisticResponse] = useOptimistic(
    response,
    (currentState: FavoriteResponse, optimisticUpdate: FavoriteResponse) => ({
      ...currentState, // 今回は不要だが、他のプロパティがある場合には必要
      ...optimisticUpdate,
    })
  );

  const postLike = useCallback(() => {
-    if (isPending) {
-      return;
-    }

    startTransition(async () => {
      addOptimisticResponse({
        isFavorite: !optimisticResponse.isFavorite,
        favoriteCount: optimisticResponse.favoriteCount + (optimisticResponse.isFavorite ? -1 : 1),
      });

      try {
        const res = await axios.post<FavoriteResponse>(
          "https://example.com/favorite",
        );
        setResponse(res.data);
      } catch (error) {
        setResponse(response);
        console.error("Failed to update favorite:", error);
      }
    });
+  }, [optimisticResponse, response, addOptimisticResponse]);

  return (
    <div
+     className="flex min-w-16 cursor-pointer flex-row items-center justify-center gap-2"
      onClick={postLike}
    >
      {optimisticResponse.isFavorite ? <MdFavorite /> : <MdFavoriteBorder />}
      <div className="text-2xl">{optimisticResponse.favoriteCount}</div>
    </div>
  );
};
完成したコード
import axios from "axios";
import { useState, useCallback, useTransition, useOptimistic } from "react";
import { MdFavorite, MdFavoriteBorder } from "react-icons/md";
import { FavoriteResponse } from "../types";

export const FavoriteButton = () => {
  const [response, setResponse] = useState<FavoriteResponse>({
    isFavorite: false,
    favoriteCount: 0,
  });
  const [, startTransition] = useTransition();
  const [optimisticResponse, addOptimisticResponse] = useOptimistic(
    response,
    (currentState: FavoriteResponse, optimisticUpdate: FavoriteResponse) => ({
      ...currentState, // 今回は不要だが、他のプロパティがある場合には必要
      ...optimisticUpdate,
    }),
  );

  const postLike = useCallback(() => {
    addOptimisticResponse({
      isFavorite: !optimisticResponse.isFavorite,
      favoriteCount:
        optimisticResponse.favoriteCount +
        (optimisticResponse.isFavorite ? -1 : 1),
    });
    startTransition(async () => {
      try {
        const res = await axios.post<FavoriteResponse>(
          "https://example.com/favorite",
        );
        setResponse(res.data);
      } catch (error) {
        setResponse(response);
        console.error("Failed to update favorite:", error);
      }
    });
  }, [optimisticResponse, response, addOptimisticResponse]);

  return (
    <div
      className="flex min-w-16 cursor-pointer flex-row items-center justify-center gap-2"
      onClick={postLike}
    >
      {optimisticResponse.isFavorite ? <MdFavorite /> : <MdFavoriteBorder />}
      <div className="text-2xl">{optimisticResponse.favoriteCount}</div>
    </div>
  );
};

完成イメージ.gif

実装中に発生したエラー

  • An optimistic state update occurred outside a transition or action. To fix, move the update to an action, or wrap with startTransition.
    • addOptimisticResponsestartTransitionの外で呼び出していた為、エラーが発生していました。
    • startTransitionの中で呼び出す事で、エラーが解消されました!

まとめ

  • useOptimistic を使った楽観的 UI 更新、いかがでしたか?
  • 従来の「クリック → 待機 → 更新」から、「クリック → 即時反応 → バックグラウンドで更新」へ。この小さな変化が、ユーザー体験を大きく向上させると思います!
  • ただし、まだ実験的な機能なので、本番環境での使用は慎重に。React の今後のアップデートにも注目しましょう。
  • ぜひ、あなたのプロジェクトでも試してみてください。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.